import copy
import json
import os
import re
from collections import Counter, defaultdict
from io import BytesIO
from pathlib import Path
from typing import NamedTuple, Callable, Any
from typing import Union, Dict, List, Tuple, Optional

import cv2
import fitz
import numpy as np
from PIL import Image

from app.utils.logger import get_logger

logger = get_logger(__name__)

# 定义连续页数阈值
MIN_CONTINUOUS_PAGES = 1
# 定义区域扩大系数(用于投影失败的策略)
AREA_EXPANSION_FACTOR = 0.1


class PageNumberRecord(NamedTuple):
    num: int
    page_index: int
    region: str  # "header" or "footer"
    alignment: str  # "left" or "right"
    bbox: fitz.Rect


def get_alignment(bbox: fitz.Rect, page_width: float) -> str:
    """
    根据边界框位置确定文本对齐方式
    
    Args:
        bbox: 文本边界框
        page_width: 页面宽度
        
    Returns:
        str: 对齐方式 ("left", "right" 或 "center")
    """
    center_x = (bbox.x0 + bbox.x1) / 2
    if center_x <= page_width * 0.2:
        return "left"
    elif center_x >= page_width * 0.8:
        return "right"
    return "center"


def is_consecutive_pages(pages: List[int]) -> bool:
    """
    检查页面索引是否连续
    
    Args:
        pages: 页面索引列表
        
    Returns:
        bool: 如果连续返回True，否则返回False
    """
    return pages == list(range(pages[0], pages[-1] + 1))


def is_consecutive_numbers(nums: List[int]) -> bool:
    """
    检查数字是否连续递增
    
    Args:
        nums: 数字列表
        
    Returns:
        bool: 如果连续返回True，否则返回False
    """
    return len(nums) >= MIN_CONTINUOUS_PAGES and all(
        nums[i] + 1 == nums[i + 1] for i in range(len(nums) - 1)
    )


def is_stable_position(records: List[PageNumberRecord], tolerance: float = 20.0) -> bool:
    """
    检查页码记录是否在相对固定的位置（允许一定浮动）

    Args:
        records: 页码记录列表
        tolerance: 位置容忍度（像素）

    Returns:
        bool: 如果位置相对稳定返回True，否则返回False
    """
    if len(records) < 2:
        return True

    # 计算所有记录位置的中心点
    x_centers = [(r.bbox.x0 + r.bbox.x1) / 2 for r in records]
    y_centers = [(r.bbox.y0 + r.bbox.y1) / 2 for r in records]

    # 计算平均位置
    avg_x = sum(x_centers) / len(x_centers)
    avg_y = sum(y_centers) / len(y_centers)

    # 检查所有点是否在平均位置的容忍度范围内
    for x, y in zip(x_centers, y_centers):
        if abs(x - avg_x) > tolerance or abs(y - avg_y) > tolerance:
            return False

    return True


def get_pages_from_range(source: Union[fitz.Document, str],
                         page_range: Optional[Union[Tuple[int, int], List[int]]] = None) -> List[fitz.Page]:
    """
    根据页面范围获取页面列表
    支持直接传入 PyMuPDF 的 Document 对象或 PDF 文件路径

    Args:
        source: 可以是 fitz.Document 或 str (PDF文件路径)
        page_range: 页面范围，用于指定要处理的页面
            - None: 处理所有页面（默认）
            - Tuple[int, int]: 起始和结束页面索引的元组，表示一个连续范围（包含两端）
              例如: (0, 3) 表示处理第1到第4页
            - List[int]: 具体页面索引的列表，可以是任意不连续的页面
              例如: [0, 2, 5] 表示处理第1、3、6页

    Returns:
        list: 页面对象列表 List[fitz.Page]

    Raises:
        TypeError: 当 source 类型不受支持时
        ValueError: 当参数组合不合法时
        Exception: 当打开文件失败时
    """
    pages = []

    # 如果 source 是字符串或Path对象（文件路径），则打开 PDF
    if isinstance(source, (str, os.PathLike)):
        try:
            doc = fitz.open(str(source))
        except Exception as e:
            logger.error(f"打开 PDF 文件失败: {e}")
            raise

        if page_range is None:
            # 如果没有指定范围，返回所有页面
            pages = [doc[i] for i in range(len(doc))]
        elif isinstance(page_range, tuple) and len(page_range) == 2:
            # 如果是元组，表示起始和结束页面（包含）
            start, end = page_range
            start = max(0, start)
            end = min(len(doc) - 1, end)
            pages = [doc[i] for i in range(start, end + 1)]
        elif isinstance(page_range, list):
            # 如果是列表，表示具体的页面索引
            for i in page_range:
                if 0 <= i < len(doc):
                    pages.append(doc[i])
        else:
            # 默认返回所有页面
            pages = [doc[i] for i in range(len(doc))]
        # 注意：在这种情况下，调用方需要负责关闭 doc

    elif isinstance(source, fitz.Document):
        # 原有的处理 fitz.Document 的逻辑
        doc = source
        if page_range is None:
            # 如果没有指定范围，返回所有页面
            pages = [doc[i] for i in range(len(doc))]
        elif isinstance(page_range, tuple) and len(page_range) == 2:
            # 如果是元组，表示起始和结束页面（包含）
            start, end = page_range
            start = max(0, start)
            end = min(len(doc) - 1, end)
            pages = [doc[i] for i in range(start, end + 1)]
        elif isinstance(page_range, list):
            # 如果是列表，表示具体的页面索引
            for i in page_range:
                if 0 <= i < len(doc):
                    pages.append(doc[i])
        else:
            # 默认返回所有页面
            pages = [doc[i] for i in range(len(doc))]

    else:
        # 不支持的类型
        raise TypeError(f"不支持的 source 类型: {type(source)}. "
                        f"支持的类型: fitz.Document, str (PDF文件路径)")

    return pages


def detect_page_number_region(source, page_range: Optional[Union[Tuple[int, int], List[int]]] = None) -> Optional[
    fitz.Rect]:
    """
    检测文档中的页码区域
    
    Args:
        source: 可以是 fitz.Document, str (PDF文件路径), pathlib.Path 或 fitz.Page
        page_range: 页面范围，可以是元组(start, end)表示范围，或列表表示具体页面索引
        
    Returns:
        Optional[fitz.Rect]: 检测到的页码区域，如果未检测到则返回None
    """
    doc = None
    need_close = False

    try:
        # 如果是字符串或Path对象，认为是PDF文件路径
        if isinstance(source, (str, os.PathLike)):
            doc = fitz.open(str(source))
            need_close = True
        # 如果是fitz.Document对象
        elif isinstance(source, fitz.Document):
            doc = source
        # 如果是fitz.Page对象
        elif isinstance(source, fitz.Page):
            # 对于单个页面，我们创建一个只包含该页面的文档
            # 注意：这在实际应用中可能不太有用，因为我们至少需要几页来检测连续的页码
            logger.info("单个页面无法有效检测页码区域，至少需要多页来判断连续性")
            return None
        else:
            logger.error(f"不支持的source类型: {type(source)}")
            return None

        if not doc:
            logger.error("无法创建或获取PDF文档")
            return None

        # 获取要处理的页面范围
        pages_to_process = get_pages_from_range(doc, page_range)

        total_pages = len(pages_to_process)
        if total_pages < MIN_CONTINUOUS_PAGES:
            logger.info("文档页数不足，跳过检测")
            return None

        sample_n = min(total_pages, max(15, MIN_CONTINUOUS_PAGES))
        all_records: List[PageNumberRecord] = []
        # 存储不连续的记录用于二次检查
        discontinuous_records: List[PageNumberRecord] = []

        # 只处理指定范围内的页面
        for idx in range(sample_n):
            # page_index = pages_to_process[idx] if isinstance(pages_to_process, list) else pages_to_process[idx].number
            # page = doc[page_index] if isinstance(pages_to_process, list) else pages_to_process[idx]
            # 修复：统一使用页面对象而不是页面索引
            page = pages_to_process[idx]
            page_index = page.number  # 获取页面的真实索引
            # 正文区域
            roi = pdf_get_content_region(page, zoom_factor=1.0)
            if not roi:
                continue

            x0, y0, x1, y1 = roi
            y1 = y1 - 25  # 增加页脚检测区域25像素
            w = page.rect.width
            h = page.rect.height

            # 扩大 header 和 footer 区域用于二次检查
            header_height = y0
            footer_height = h - y1
            expanded_header_rect = fitz.Rect(0, 0, w, y0 + header_height * AREA_EXPANSION_FACTOR)  # 扩大10%
            expanded_footer_rect = fitz.Rect(0, y1 - footer_height * AREA_EXPANSION_FACTOR, w, h)  # 扩大10%

            # 严格使用 y0 和 y1 定义排除区域
            header_rect = fitz.Rect(0, 0, w, y0)  # 顶部排除区
            footer_rect = fitz.Rect(0, y1, w, h)  # 底部排除区
            # show_page_image(page, 1.0, True, header_rect.irect)
            # show_page_image(page, 1.0, True, roi)
            # show_page_image(page, 1.0, True, footer_rect.irect)

            # 用于存储当前页面的页码记录，确保每个页面只有一个有效页码
            page_records = []

            # 在 header_rect 中提取候选
            for block in page.get_text("dict", clip=header_rect, sort=True).get("blocks", []):
                for line in block.get("lines", []):
                    for span in line.get("spans", []):
                        text = span["text"]
                        bbox = fitz.Rect(span["bbox"])
                        for match in re.finditer(r'\b(\d+)\b', text):
                            try:
                                num = int(match.group(1))
                                if num <= 0:
                                    continue
                            except ValueError:
                                continue
                            align = get_alignment(bbox, w)
                            if align in ("left", "right"):
                                # 注意：这里使用实际的页面索引，而不是循环索引
                                page_records.append(PageNumberRecord(num, page_index, "header", align, bbox))

            # 在 footer_rect 中提取候选
            for block in page.get_text("dict", clip=footer_rect).get("blocks", []):
                for line in block.get("lines", []):
                    for span in line.get("spans", []):
                        text = span["text"]
                        bbox = fitz.Rect(span["bbox"])
                        for match in re.finditer(r'\b(\d+)\b', text):
                            try:
                                num = int(match.group(1))
                                if num <= 0:
                                    continue
                            except ValueError:
                                continue
                            align = get_alignment(bbox, w)
                            if align in ("left", "right"):
                                # 注意：这里使用实际的页面索引，而不是循环索引
                                page_records.append(PageNumberRecord(num, page_index, "footer", align, bbox))

            # 如果当前页面没有找到页码，则在扩大区域中二次查找
            if not page_records:
                # 在扩展的 header 区域中查找
                for block in page.get_text("dict", clip=expanded_header_rect, sort=True).get("blocks", []):
                    for line in block.get("lines", []):
                        for span in line.get("spans", []):
                            text = span["text"]
                            bbox = fitz.Rect(span["bbox"])
                            for match in re.finditer(r'\b(\d+)\b', text):
                                try:
                                    num = int(match.group(1))
                                    if num <= 0:
                                        continue
                                except ValueError:
                                    continue
                                align = get_alignment(bbox, w)
                                if align in ("left", "right"):
                                    discontinuous_records.append(
                                        PageNumberRecord(num, page_index, "header", align, bbox))

                # 在扩展的 footer 区域中查找
                for block in page.get_text("dict", clip=expanded_footer_rect).get("blocks", []):
                    for line in block.get("lines", []):
                        for span in line.get("spans", []):
                            text = span["text"]
                            bbox = fitz.Rect(span["bbox"])
                            for match in re.finditer(r'\b(\d+)\b', text):
                                try:
                                    num = int(match.group(1))
                                    if num <= 0:
                                        continue
                                except ValueError:
                                    continue
                                align = get_alignment(bbox, w)
                                if align in ("left", "right"):
                                    discontinuous_records.append(
                                        PageNumberRecord(num, page_index, "footer", align, bbox))
            else:
                # 如果当前页面有多个页码候选，选择最合适的一个
                # 根据页码数值和位置选择最合适的记录
                # 优先选择接近页面索引+1的页码（通常页码从1开始）
                best_record = min(page_records, key=lambda r: abs(r.num - (r.page_index + 1)))
                all_records.append(best_record)

        # 将不连续的记录也加入到主记录列表中，用于后续分析
        all_records.extend(discontinuous_records)

        if not all_records:
            return None

        # 按页面索引排序
        all_records.sort(key=lambda x: x.page_index)
        logger.info(f"检测到有效页码: {[(record.page_index, record.num) for record in all_records]}")
        return all_records
        # 按区域和对齐方式分组
        clusters = defaultdict(list)
        for rec in all_records:
            clusters[(rec.region, rec.alignment)].append(rec)

        # 检查每个分组是否符合页码规律
        for (region, align), group in clusters.items():
            if len(group) < MIN_CONTINUOUS_PAGES:
                continue

            # 按页面索引排序
            group.sort(key=lambda x: x.page_index)

            # 提取页面索引和页码
            pages = [r.page_index for r in group]
            nums = [r.num for r in group]

            # 检查页面索引是否连续
            if not is_consecutive_pages(pages):
                # 将不连续的对象信息存储用于进一步分析
                continue

            # 检查页码是否连续递增
            if not is_consecutive_numbers(nums):
                continue

            # 检查坐标是否相对固定（允许一定浮动）
            if is_stable_position(group):
                # 返回第一页对应的区域
                # 使用范围内的第一页而不是文档的第一页
                first_page_index = pages[0]
                first_page = doc[first_page_index]
                roi0 = pdf_get_content_region(first_page, zoom_factor=1.0)
                if not roi0:
                    continue
                _, y0_0, _, y1_0 = roi0
                w0 = first_page.rect.width
                h0 = first_page.rect.height
                # 对于扩展区域检测到的页码，我们也适当扩展返回的区域
                if region == "header":
                    header_height_0 = y0_0
                    return fitz.Rect(0, 0, w0, y0_0 + header_height_0 * AREA_EXPANSION_FACTOR)
                else:  # footer
                    footer_height_0 = h0 - y1_0
                    return fitz.Rect(0, y1_0 - footer_height_0 * AREA_EXPANSION_FACTOR, w0, h0)

        return None

    finally:
        # 如果我们打开了文档，则需要关闭它
        if need_close and doc:
            doc.close()


def pdf_pages_to_images(pages: List[fitz.Page], zoom_factor: float = 1.0, color_mode: str = "BGR") -> List[np.ndarray]:
    """
    将 PyMuPDF 页面对象列表转换为 OpenCV 图像列表。

    Args:
        pages: fitz.Page 对象列表。
        zoom_factor: 缩放因子。
        color_mode: 目标颜色模式 ("RGB" 或 "BGR")。

    Returns:
        List[np.ndarray]: OpenCV 格式的图像列表。
    """
    images = []
    for page in pages:
        img = page_to_img(page, zoom_factor, color_mode)
        if img is not None:
            images.append(img)
    return images


def page_to_img(page, zoom_factor=1.0, color_mode="RGB"):
    """
    将PDF页面转换为图像
    Args:
        page (fitz.Page): PyMuPDF 页面对象
        zoom_factor (float): 缩放因子，用于调整图像分辨率，默认为 1.0
            注: 仅对 fitz.Page 有效
            - 1.0 表示原始分辨率
            - 2.0 表示两倍分辨率（宽高各增加一倍）
            - 0.5 表示 half 分辨率（宽高各减少一半）
        color_mode: 颜色模式，默认为 "RGB"

    Returns:

    Raises:
        TypeError: 当 page 不是 fitz.Page 类型时
        Exception: 当页面转换为图像失败时抛出异常
    """

    if not hasattr(page, 'get_pixmap'):
        raise TypeError(f"page 参数必须是 fitz.Page 对象，得到的是 {type(page)}")

    try:
        mat = fitz.Matrix(zoom_factor, zoom_factor)
        pix = page.get_pixmap(matrix=mat)
        # pix.samples is in RGB or RGBA order
        img_array = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
        # 只有在图像有多个通道时才进行颜色空间转换
        # pix.n：通道数
        # 3 → RGB（无透明）
        # 4 → RGBA（带 alpha）
        # 1 → 灰度（较少见，PDF 页面通常渲染为 RGB/RGBA）
        if pix.n >= 3:  # RGB 或 RGBA 图像
            if color_mode == "RGB":
                if pix.n == 4:  # 处理带 Alpha 的情况
                    img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB)
                return img_array
            elif color_mode == "BGR":
                if pix.n == 4:  # 处理带 Alpha 的情况
                    img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2BGR)
                else:
                    img_array = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
                return img_array
        else:  # 灰度图像
            if color_mode == "RGB":  # 如果需要RGB格式，则从灰度转换
                img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
            elif color_mode == "BGR":  # 如果需要BGR格式，则从灰度转换
                img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2BGR)
            return img_array

    except Exception as e:
        logger.error(f"将Page转换为图像失败: {e}")
        raise


# --- （可选增强）辅助函数：检测 NumPy 数组的颜色模式 ---
def _detect_color_mode(img_array: np.ndarray) -> str:
    """尝试推断 numpy 图像数组的颜色模式。"""
    if img_array.ndim == 2:
        return "GRAY"
    elif img_array.ndim == 3:
        channels = img_array.shape[2]
        if channels == 1:
            # 形状为 (H, W, 1) 的情况，虽然少见但理论上可能
            return "GRAY"
        elif channels == 3:
            # 简单判断：OpenCV 默认 BGR，但无法绝对确定。
            # 这里假设传入的已经是目标格式或需要转换为目标格式。
            # 实际应用中精确判断 RGB/BGR 很困难，通常依赖上下文。
            # 为简化，我们可以假设它需要被处理成目标格式。
            # 或者约定：如果传入 ndarray，就认为它已经是目标 color_mode 了。
            # 这里返回一个通用标识
            return "UNSPECIFIED_3C"  # 或者简单返回 "BGR" / "RGB" 基于约定
        elif channels == 4:
            return "UNSPECIFIED_4C"  # RGBA / BGRA
    return "UNKNOWN"


def convert_to_images(
        source: Union[
            str, Path, fitz.Document, fitz.Page, np.ndarray,
            List[Union[str, Path, fitz.Document, fitz.Page, np.ndarray]]
        ],
        zoom_factor: float = 1.0,
        page_range: Optional[Union[Tuple[int, int], List[int]]] = None,
        color_mode: str = "BGR",
        strict: bool = True  # 新增参数：控制错误处理策略
) -> Tuple[List[np.ndarray], List[Any]]:
    """
    将各种源（PDF 文件、页面、现有图像）转换为 OpenCV 图像列表。

    设计理念：
    - 保持核心转换逻辑 (`page_to_img`) 的单一职责。
    - 将 PDF 加载 (`load_pdf_pages`) 和转换 (`pdf_pages_to_images`) 逻辑分离。
    - 主函数 `convert_to_images` 负责调度、处理多种输入类型和组合。
    - 提供灵活的错误处理选项。

    注意事项：
    - `zoom_factor` 和 `color_mode` 主要应用于从 PDF 页面生成图像。
    - `page_range` 仅适用于 PDF 文件或文档源 (str, Path, fitz.Document)。
      对于单个 `fitz.Page`，`page_range` 被忽略。
      对于 `np.ndarray`，`page_range` 被忽略。
    - 对于已有的 `numpy.ndarray` 图像：
       - 默认行为 (`strict=False` 或未实现增强时)：直接添加，忽略 `zoom_factor` 和 `color_mode`。
       - 增强行为 (实现后)：可选择应用 `zoom_factor` (重采样) 和 `color_mode` (如果需要转换)。
         (此增强特性在下方 TODO 中有初步实现)
    - `color_mode` 参数决定了输出图像列表中所有图像的最终颜色通道顺序。

    Args:
        source: 输入源。
            - str / Path: PDF 文件路径。
            - fitz.Document: 已打开的 PyMuPDF 文档。
            - fitz.Page: 单个 PyMuPDF 页面。
            - np.ndarray: 已存在的图像 (例如 OpenCV 格式)。
                                 必须是 uint8 类型。
            - List/Tuple: 上述类型的混合列表。
        zoom_factor (float): 缩放因子 (用于 PDF 页面)。默认 1.0。
                             > 1.0 放大, 0 < value < 1.0 缩小。
        page_range: 页面范围 (用于 PDF 文件/文档)。
            - None: 处理所有页面（默认）。
            - Tuple[int, int]: 起始和结束索引的元组 (包含两端)。
                               例如: (0, 3) 表示处理第1到第4页 (索引 0, 1, 2, 3)。
            - List[int]: 具体页面索引的列表。
                         例如: [0, 2, 5] 表示处理第1、3、6页。
        color_mode (str): 目标颜色模式 ("RGB" 或 "BGR")。默认 "BGR"。
                          所有输出图像都将（尽可能）转换为此模式。
        strict (bool): 错误处理策略。
                       - True (默认): 遇到任何无法处理的源或转换错误时，
                                     立即抛出异常中断整个过程。
                       - False: 记录警告或错误日志，跳过有问题的源或图像，
                                继续处理列表中的其余源，尽力返回已成功处理的结果。

    Returns:
        Tuple[List[np.ndarray], List[Any]]:
            - 第一个元素 (List[np.ndarray]): 成功转换/加载的 OpenCV 格式图像列表。
                                             列表按输入顺序（深度优先）展开。
                                             每个元素都是 uint8 类型的 numpy 数组。
            - 第二个元素 (List[Any]): 如果 `strict=False` 且有源处理失败，
                                     此列表包含导致失败的原始 `source` 元素。
                                     如果 `strict=True` 或没有失败，则为空列表 `[]`。

    Raises:
        TypeError: 当 `source` 或其元素类型不被支持时 (strict=True)。
        ValueError: 当 `zoom_factor`, `page_range`, `color_mode` 参数值无效时 (strict=True)。
        Exception: 当处理任何源（尤其是 PDF）遇到无法恢复的错误时 (strict=True)。
                   注意：单个源的失败在 `strict=False` 时不会引发此级别异常。
    """

    # --- 1. 输入参数验证 ---
    failed_sources = []  # 用于存储失败的源 (strict=False 时)

    # 验证 color_mode
    if not isinstance(color_mode, str) or color_mode.upper() not in ["RGB", "BGR"]:
        error_msg = f"color_mode 必须是 'RGB' 或 'BGR' 字符串, 得到 '{color_mode}' (类型: {type(color_mode)})"
        if strict:
            raise ValueError(error_msg)
        else:
            logger.error(error_msg)
            # 如果 color_mode 本身无效，可能影响所有后续处理，根据策略决定
            # 这里选择中断，因为这是一个全局配置错误
            raise ValueError(error_msg)  # 或者 return ([], [source]) ?

    color_mode = color_mode.upper()  # 标准化

    # 验证 zoom_factor
    if not isinstance(zoom_factor, (int, float)) or zoom_factor <= 0:
        error_msg = f"zoom_factor 必须是大于 0 的数字, 得到 '{zoom_factor}' (类型: {type(zoom_factor)})"
        if strict:
            raise ValueError(error_msg)
        else:
            logger.error(error_msg)
            raise ValueError(error_msg)  # 全局配置错误，倾向于中断

    # 验证 page_range (基本类型)
    if page_range is not None:
        if not (isinstance(page_range, (tuple, list))):
            error_msg = f"page_range 必须是 None, tuple 或 list, 得到 '{page_range}' (类型: {type(page_range)})"
            if strict:
                raise TypeError(error_msg)
            else:
                logger.error(error_msg)
                # page_range 错误通常只影响 PDF 源，可以选择记录并继续
                # 但为了清晰，这里也 raise
                raise TypeError(error_msg)

    all_images = []
    sources_to_process = source if isinstance(source, (list, tuple)) else [source]

    # --- 2. 处理每个源 ---
    for i, src in enumerate(sources_to_process):
        try:
            # --- 2.1 处理已经是图像数组的情况 ---
            if isinstance(src, np.ndarray):
                # 基本验证 numpy array
                if src.dtype != np.uint8:
                    logger.warning(f"Source {i} (numpy array) 数据类型不是 uint8, 可能导致问题: {src.dtype}")
                    # 可以选择转换或报错，这里暂不处理

                logger.debug(f"Source {i} is a numpy array, adding directly.")
                # TODO (可选增强): 应用 zoom_factor (重采样) 和 color_mode
                processed_img = src
                if True:  # 如果启用了增强处理 TODO
                    try:
                        current_mode = _detect_color_mode(src)
                        target_mode = color_mode

                        # 简化的颜色模式转换逻辑 (可根据需要扩展)
                        if current_mode == "GRAY" and target_mode in ["RGB", "BGR"]:
                            processed_img = cv2.cvtColor(src, getattr(cv2, f'COLOR_GRAY2{target_mode}'))
                        elif current_mode.startswith("UNSPECIFIED_3C") and target_mode in ["RGB", "BGR"] and current_mode != f"UNSPECIFIED_3C_{target_mode}":
                            # 简单假设输入是 RGB (PyMuPDF get_pixmap 默认)，目标是 BGR (反之亦然)
                            if target_mode == "BGR":
                                processed_img = cv2.cvtColor(src, cv2.COLOR_RGB2BGR)
                            elif target_mode == "RGB":
                                processed_img = cv2.cvtColor(src, cv2.COLOR_BGR2RGB)  # 如果输入被认为是 BGR
                            # 注意：这里的假设可能不总是对，最好在传入 ndarray 时就约定好格式
                        # 可以添加更多模式转换...

                        # 应用缩放 (如果需要)
                        if zoom_factor != 1.0:
                            height, width = processed_img.shape[:2]
                            new_width = int(round(width * zoom_factor))
                            new_height = int(round(height * zoom_factor))
                            # 避免除以零
                            if new_width > 0 and new_height > 0:
                                # INTER_AREA 通常用于缩小，INTER_LINEAR 用于放大或缩小
                                interpolation = cv2.INTER_AREA if zoom_factor < 1.0 else cv2.INTER_LINEAR
                                processed_img = cv2.resize(processed_img, (new_width, new_height), interpolation=interpolation)
                            else:
                                logger.warning(f"Zoom factor {zoom_factor} 导致图像尺寸变为 0，跳过缩放。")
                    except Exception as proc_e:
                        error_msg = f"处理 numpy array 源 {i} 时应用 zoom/color 失败: {proc_e}"
                        if strict:
                            raise RuntimeError(error_msg) from proc_e
                        else:
                            logger.error(error_msg)
                            failed_sources.append(src)
                            continue  # 跳过此图像

                all_images.append(processed_img)

            # --- 2.2 处理单个 PDF 页面 ---
            elif isinstance(src, fitz.Page):
                logger.debug(f"Source {i} is a fitz.Page object, converting...")
                # 对于单个页面，忽略外部传入的 page_range
                img = page_to_img(src, zoom_factor, color_mode)  # page_to_img 内部应已处理 color_mode
                if img is not None:
                    all_images.append(img)
                else:
                    # page_to_img 内部应该已记录具体错误
                    error_msg = f"未能将 fitz.Page 源 {i} 转换为图像。"
                    if strict:
                        raise RuntimeError(error_msg)
                    else:
                        logger.error(error_msg)
                        failed_sources.append(src)

            # --- 2.3 处理 PDF 文件或文档对象 ---
            elif isinstance(src, (str, Path, fitz.Document)):
                logger.debug(f"Source {i} is a PDF source ({type(src)}), loading pages and converting...")
                # 对于 PDF 源，应用 page_range 和 zoom_factor
                pages = get_pages_from_range(src, page_range)  # 内部处理了 Document 打开/关闭
                images = pdf_pages_to_images(pages, zoom_factor, color_mode)  # 内部调用 page_to_img
                all_images.extend(images)
                # load_pdf_pages 或 pdf_pages_to_images 内部应处理其各自的错误

            else:
                error_msg = f"不支持的源类型 at index {i}: {type(src)} (值: {src})"
                if strict:
                    raise TypeError(error_msg)
                else:
                    logger.error(error_msg)
                    failed_sources.append(src)  # 记录失败的源

        except Exception as e:
            # 捕获处理单个源时发生的任何未被内部处理的异常
            error_msg = f"处理源 {i} ({src}) 时发生未预期错误: {e}"
            if strict:
                logger.error(error_msg)  # 先记录
                raise  # 重新抛出，中断流程
            else:
                logger.error(error_msg)
                failed_sources.append(src)  # 记录失败的源

    return all_images, failed_sources


# 查找正文区域
def pdf_get_content_region(
        source: Union[fitz.Page, np.ndarray],
        zoom_factor: float = 1.0,
        is_show_log: bool = False,
        top_margin: int = 60,  # 新增顶部边距参数，默认60
        bottom_margin: int = 35,  # 新增底部边距参数，默认35
        left_right_margin: Tuple[int, int] = (50, 150),  # 新增左右边距参数，默认(50, 150)
        skip_next_line_left: int = 20,  # 左侧跳过的像素数，默认20
        skip_next_line_right: int = 30  # 右侧跳过的像素数，默认30
) -> Optional[Tuple[int, int, int, int]]:
    """
    检测 PDF 页面或图像中的垂直白色区域，以确定正文 ROI。
    此函数支持直接传入 PyMuPDF 的 Page 对象或 OpenCV 的 numpy 图像。

    Args:
        source (fitz.Page or np.ndarray): PDF 页面对象或 OpenCV 图像矩阵。
        zoom_factor (float): 缩放因子，用于调整检测区域的大小。
        is_show_log (bool): 是否打印日志信息。
        top_margin (int): 顶部需要排除的像素数。默认值为 60。这个参数允许你根据具体页面的页眉高度动态调整。
        bottom_margin (int): 底部需要排除的像素数。默认值为 35。允许你根据页脚的高度进行调整
        left_right_margin (Tuple[int, int]): 左右两侧扫描区域的范围，格式为(left_start, left_end)。默认值为 (50, 150)。这允许你自定义从哪个位置开始扫描左侧和右侧的空白区域
        skip_next_line_left (int): 左侧跳过的像素数。
        skip_next_line_right (int): 右侧跳过的像素数。

    Returns:
        Optional[Tuple[int, int, int, int]]: 返回 ROI 的 (x0, y0, x1, y1) 坐标，如果检测失败则返回 None。
    """
    img_cv = None
    if isinstance(source, fitz.Page):
        try:
            mat = fitz.Matrix(zoom_factor, zoom_factor)
            pix = source.get_pixmap(matrix=mat, colorspace=fitz.csGRAY)
            img_cv = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width)
        except Exception as e:
            logger.error(f"将 Page 转换为图像失败: {e}")
            return None
    elif isinstance(source, np.ndarray):
        img_cv = source
        if len(img_cv.shape) == 3:
            img_cv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY)
        elif len(img_cv.shape) != 2:
            logger.info("图像维度不正确")
            return None
    else:
        logger.info("source 必须是 fitz.Page 或 numpy.ndarray")
        return None

    height, width = img_cv.shape

    def find_vertical_white_regions(area_list: list, x: int):
        white_count = 0
        start_y = None
        start_height = max(0, int(top_margin * zoom_factor))  # 顶部需要排除的像素数

        for y in range(height):
            if x < int(skip_next_line_left * zoom_factor) or x >= width - int(skip_next_line_right * zoom_factor):
                continue
            if y <= start_height:
                continue

            if img_cv[y, x] == 255:  # 白色像素
                if white_count == 0:
                    start_y = y
                white_count += 1
                required_length = height - start_height - 5  # 允许少量误差
                if white_count >= required_length:
                    area_list.append([x, start_y, x, start_y + white_count])
                    white_count = 0
                    start_y = None
            else:
                white_count = 0
                start_y = None

    l_area = []
    r_area = []

    left_start = int(left_right_margin[0] * zoom_factor)
    left_end = int(left_right_margin[1] * zoom_factor)
    right_start = width - int(left_right_margin[1] * zoom_factor)
    right_end = width - int(left_right_margin[0] * zoom_factor)

    for x in range(width):
        if left_start < x < left_end:
            find_vertical_white_regions(l_area, x)
        if right_start < x < right_end:
            find_vertical_white_regions(r_area, x)

    if len(l_area) == 0 or len(r_area) == 0:
        logger.info(f"警告: 未在指定范围内找到足够的垂直白色区域。l_area={len(l_area)}, r_area={len(r_area)}")
        if is_show_log and isinstance(source, np.ndarray):
            cv2.imshow("Detected Regions", img_cv)
            cv2.waitKey(0)
            cv2.destroyAllWindows()
        return None

    if is_show_log:
        logger.info(f'l_area size={len(l_area)}, data={l_area}')
        logger.info(f'r_area size={len(r_area)}, data={r_area}')

    try:
        left_rect = l_area[len(l_area) // 2]
        right_rect = r_area[len(r_area) // 2]

        # 使用实际检测到的y坐标作为y0的起点
        detected_y0 = max(left_rect[1], right_rect[1])

        x0 = left_rect[0]
        x1 = right_rect[0]
        y0 = detected_y0
        y1 = height - int(bottom_margin * zoom_factor)  # 计算底部

        if x0 >= x1:
            x0, x1 = max(0, x0 - 50), min(width, x1 + 50)  # 计算左上角忽略区域

        if isinstance(source, fitz.Page):
            x0 = int(x0 / zoom_factor)
            y0 = int(y0 / zoom_factor)
            x1 = int(x1 / zoom_factor)
            y1 = int(y1 / zoom_factor)
            # logger.info(f"MediaBox: {source.rect}, 计算出的 ROI: ({x0}, {y0}, {x1}, {y1})")

        return x0, y0, x1, y1

    except Exception as e:
        logger.warn(f"计算 ROI 坐标时出错: {e}")
        return None


def pdf_get_content_region_v2(
        source: Union[fitz.Page, np.ndarray, str],
        zoom_factor: float = 1.0,
        is_show_log: bool = False,
        top_margin: int = 60,
        bottom_margin: int = 35,
        left_right_margin: Tuple[int, int] = (50, 150),
        skip_next_line_left: int = 20,
        skip_next_line_right: int = 30,
        page_range: Optional[Union[Tuple[int, int], List[int]]] = None
) -> Optional[List[Tuple[int, int, int, int]]]:
    """
    检测 PDF 页面或图像中的垂直白色区域，以确定正文 ROI。
    此函数支持直接传入 PyMuPDF 的 Page 对象、OpenCV 的 numpy 图像或 PDF 文件路径。
    
    与 pdf_get_content_region 相比，此版本支持：
    1. str 类型的文件路径作为输入
    2. page_range 参数处理多个页面范围输入，如 (0, 3) 表示处理第1到第4页
    3. 返回 page_range 列表对应 ROI 的元组 (x0, y0, x1, y1)

    Args:
        source (fitz.Page, np.ndarray or str): PDF 页面对象、OpenCV 图像矩阵或 PDF 文件路径。
        zoom_factor (float): 缩放因子，用于调整检测区域的大小。
        is_show_log (bool): 是否打印日志信息。
        top_margin (int): 顶部需要排除的像素数。默认值为 60。
        bottom_margin (int): 底部需要排除的像素数。默认值为 35。
        left_right_margin (Tuple[int, int]): 左右两侧扫描区域的范围，格式为(left_start, left_end)。默认值为 (50, 150)。
        skip_next_line_left (int): 左侧跳过的像素数。
        skip_next_line_right (int): 右侧跳过的像素数。
        page_range: 页面范围，可以是元组(start, end)表示范围，或列表表示具体页面索引,参数处理多个页面范围输入，如 (0, 3) 表示处理第1到第4页

    Returns:
        Optional[List[Tuple[int, int, int, int]]]: 返回 ROI 的 (x0, y0, x1, y1) 坐标列表，如果检测失败则返回 None。
    """
    # 如果 source 是字符串或Path对象（文件路径），则打开 PDF
    if isinstance(source, (str, os.PathLike)):
        try:
            doc = fitz.open(str(source))
        except Exception as e:
            logger.error(f"打开 PDF 文件失败: {e}")
            raise
            return None

        rois = []
        try:
            # 获取要处理的页面范围
            pages_to_process = get_pages_from_range(doc, page_range)

            # 遍历指定范围内的页面
            for page in pages_to_process:
                roi = pdf_get_content_region(
                    source=page,
                    zoom_factor=zoom_factor,
                    is_show_log=is_show_log,
                    top_margin=top_margin,
                    bottom_margin=bottom_margin,
                    left_right_margin=left_right_margin,
                    skip_next_line_left=skip_next_line_left,
                    skip_next_line_right=skip_next_line_right
                )
                rois.append(roi)

            return rois if rois else None
        finally:
            doc.close()

    # 如果 source 是 fitz.Page 或 np.ndarray，则直接调用 pdf_get_content_region
    elif isinstance(source, (fitz.Page, np.ndarray)):
        roi = pdf_get_content_region(
            source=source,
            zoom_factor=zoom_factor,
            is_show_log=is_show_log,
            top_margin=top_margin,
            bottom_margin=bottom_margin,
            left_right_margin=left_right_margin,
            skip_next_line_left=skip_next_line_left,
            skip_next_line_right=skip_next_line_right
        )
        return [roi] if roi else None

    else:
        logger.info("source 必须是 fitz.Page、numpy.ndarray 或 str（文件路径）")
        return None


def crop_pdf_with_redactions(
        input_path: str,
        output_path: str,
        roi_detector: Callable[[fitz.Page], Optional[Tuple[int, int, int, int]]],
        page_range=None,
        zero_based=True,
        detector_kwargs: dict = None
):
    """
    方案1: 使用 add_redact_annot + apply_redactions，ROI 由 roi_detector 动态提供。
      效果: 永久性删除 ROI 区域外的内容。原始数据被移除，无法恢复。新的 PDF 页面尺寸与原页面相同。

      Args:
          input_path (str): 输入 PDF 路径。
          output_path (str): 输出 PDF 路径。
          roi_detector (Callable): ROI 区域 由 roi_detector 动态提供，应该是一个接受 fitz.Page 参数并返回 Tuple[int, int, int, int] 或 None 的可调用对象。
          page_range (int, list, tuple, None): 要处理的页码。
              - int: 处理单页 (例如 0)
              - list: 处理多页 (例如 [0, 1, 2])
              - tuple: 处理范围 (例如 (0, 3) 表示处理第1到第4页)
              - None: 处理所有页
          zero_based (bool): 页码是否为 0-based。如果传入的页码是 1-based (第一页是1)，请设为 False。
          detector_kwargs: 传递给 roi_detector 的额外参数
    """
    detector_kwargs = detector_kwargs or {}
    try:
        doc = fitz.open(input_path)
        total_pages = doc.page_count
        pages_to_process = _get_pages_to_process(page_range, total_pages, zero_based)
        for page_index in pages_to_process:
            page = doc[page_index]
            # 调用检测器获取当前页的 ROI
            if callable(roi_detector):
                roi = roi_detector(page, **detector_kwargs)
            else:
                # 如果 roi_detector 不是可调用对象，直接使用它作为 ROI
                roi = roi_detector
            if roi is None:
                logger.info(f"[方案1] 第 {page_index + 1} 页: ROI 检测失败，跳过。")
                continue

            _apply_redactions_to_page(page, roi)
            logger.info(f"[方案1] 已处理第 {page_index + 1} 页")
        if output_path:
            doc.save(output_path)
            logger.info(f"[方案1] 已保存: {output_path}")
        doc.close()
    except Exception as e:
        logger.error(f"[方案1] 处理失败: {e}")
        import traceback
        traceback.print_exc()
        if 'doc' in locals():
            doc.close()
        raise  # 重新抛出异常以便调用者能处理


def crop_pdf_with_white_mask(
        input_path: str,
        output_path: str = None,
        roi_detector: Callable[[fitz.Page], Optional[Tuple[int, int, int, int]]] = None,
        page_range=None,
        zero_based=True,
        detector_kwargs: dict = None
):
    """
    方案2使用 draw_rect 遮罩，ROI 动态提供
    效果: 在 ROI 区域外绘制白色矩形进行视觉遮盖，只有 ROI 区域可见。
    原始数据仍在文件中，但被覆盖。
    新的 PDF 页面尺寸与原页面相同。

    Args:
        input_path (str): 输入 PDF 路径。
        output_path (str, optional): 输出 PDF 路径。如果为 None，则不保存到文件。
        roi_detector (Callable): ROI 动态提供，应该是一个接受 fitz.Page 参数并返回 Tuple[int, int, int, int] 或 None 的可调用对象。
        page_range (int, list, tuple, None): 要处理的页码。
            - int: 处理单页 (例如 0)
            - list: 处理多页 (例如 [0, 1, 2])
            - tuple: 处理范围 (例如 (0, 3) 表示处理第1到第4页)
            - None: 处理所有页
        zero_based (bool): 页码是否为 0-based。
        detector_kwargs: 传递给 roi_detector 的额外参数
    """
    detector_kwargs = detector_kwargs or {}
    try:
        doc = fitz.open(input_path)
        total_pages = doc.page_count
        pages_to_process = _get_pages_to_process(page_range, total_pages, zero_based)
        for page_index in pages_to_process:
            page = doc[page_index]
            if callable(roi_detector):
                roi = roi_detector(page, **detector_kwargs)
            else:
                # 如果 roi_detector 不是可调用对象，直接使用它作为 ROI
                roi = roi_detector
            if roi is None:
                logger.info(f"[方案2] 第 {page_index + 1} 页: ROI 检测失败，跳过。")
                continue
            _draw_white_mask_on_page(page, roi)
            logger.info(f"[方案2] 已处理第 {page_index + 1} 页")
        if output_path:
            doc.save(output_path)
            logger.info(f"[方案2] 已保存: {output_path}")
        doc.close()
    except Exception as e:
        logger.info(f"[方案2] 处理失败: {str(e)}")
        import traceback
        traceback.print_exc()
        if 'doc' in locals():
            doc.close()
        raise  # 重新抛出异常以便调用者能处理


def rectangles_intersect(rect1, rect2):
    """
    判断两个矩形是否相交
    rect: (x0, y0, x1, y1) 其中(x0,y0)是左上角，(x1,y1)是右下角
    """
    x0_1, y0_1, x1_1, y1_1 = rect1
    x0_2, y0_2, x1_2, y1_2 = rect2

    # 如果在X轴或Y轴上没有重叠，则不相交
    # 不相交的条件：
    # 1. rect1在rect2的左边：x1_1 < x0_2
    # 2. rect1在rect2的右边：x0_1 > x1_2
    # 3. rect1在rect2的上边：y1_1 < y0_2
    # 4. rect1在rect2的下边：y0_1 > y1_2

    return not (x1_1 < x0_2 or x0_1 > x1_2 or y1_1 < y0_2 or y0_1 > y1_2)


def rectangles_intersect_center(rect1, rect2):
    """
    通过中心点距离判断两个矩形是否相交

    Args:
        rect1: (x0, y0, x1, y1) 矩形1的坐标，(x0,y0)左上角，(x1,y1)右下角
        rect2: (x0, y0, x1, y1) 矩形2的坐标，(x0,y0)左上角，(x1,y1)右下角

    Returns:
        bool: True表示相交，False表示不相交
    """
    x0_1, y0_1, x1_1, y1_1 = rect1
    x0_2, y0_2, x1_2, y1_2 = rect2

    # 计算矩形的宽度和高度
    wa = x1_1 - x0_1
    ha = y1_1 - y0_1
    wb = x1_2 - x0_2
    hb = y1_2 - y0_2

    # 计算中心点坐标
    xa3 = (x0_1 + x1_1) / 2
    ya3 = (y0_1 + y1_1) / 2
    xb3 = (x0_2 + x1_2) / 2
    yb3 = (y0_2 + y1_2) / 2

    # 计算中心点在X轴和Y轴上的距离
    lx = abs(xb3 - xa3)
    ly = abs(yb3 - ya3)

    # 如果中心点距离小于等于边长和的一半，则相交
    return lx <= (wa + wb) / 2 and ly <= (ha + hb) / 2


def rectangles_intersect_overlap(rect1, rect2):
    """
    通过投影重叠判断两个矩形是否相交

    Args:
        rect1: (x0, y0, x1, y1) 矩形1的坐标，(x0,y0)左上角，(x1,y1)右下角
        rect2: (x0, y0, x1, y1) 矩形2的坐标，(x0,y0)左上角，(x1,y1)右下角

    Returns:
        bool: True表示相交，False表示不相交
    """
    x0_1, y0_1, x1_1, y1_1 = rect1
    x0_2, y0_2, x1_2, y1_2 = rect2

    # 计算X轴上的重叠区域
    left = max(x0_1, x0_2)
    right = min(x1_1, x1_2)

    # 计算Y轴上的重叠区域
    top = max(y0_1, y0_2)
    bottom = min(y1_1, y1_2)

    # 如果在X轴和Y轴上都有重叠，则相交
    return left < right and top < bottom


def extract_json(text_dict, cb=None, sort=False, output_path=None) -> str:
    """Return 'extractDICT' converted to JSON format."""
    import base64
    import json

    class b64encode(json.JSONEncoder):
        def default(self, s):
            if type(s) in (bytes, bytearray):
                return base64.b64encode(s).decode()
            return None

    if cb is not None:
        text_dict["width"] = cb.width
        text_dict["height"] = cb.height
    if sort:
        blocks = text_dict["blocks"]
        blocks.sort(key=lambda b: (b["bbox"][3], b["bbox"][0]))
        text_dict["blocks"] = blocks

    json_dict = json.dumps(text_dict, separators=(",", ":"), cls=b64encode, indent=4, ensure_ascii=False)
    # 保存为JSON文件
    if output_path:
        with open(output_path, "w", encoding="utf-8") as file:
            file.write(json_dict)
    return json_dict


def bbox_overlap_y(bbox1, bbox2, threshold=0.8):
    """判断两个 bbox 在 Y 轴上是否高度重叠"""
    y0_1, y1_1 = bbox1[1], bbox1[3]
    y0_2, y1_2 = bbox2[1], bbox2[3]

    # 计算重叠区间
    overlap = max(0, min(y1_1, y1_2) - max(y0_1, y0_2))
    height1 = y1_1 - y0_1
    height2 = y1_2 - y0_2

    # 使用较小高度作为分母更合理
    if min(height1, height2) == 0:
        return False
    iou_y = overlap / min(height1, height2)
    return iou_y >= threshold


def deduplicate_spans_in_blocks_with_stats(text_dict, y_overlap_threshold=0.8, verbose=True):
    """
    去除 text_dict 中重复的 spans，并打印统计信息。

    Args:
        text_dict: page.get_text("dict", sort=True) 的结果
        y_overlap_threshold: Y 轴重叠阈值（0~1）
        verbose: 是否打印详细日志

    Returns:
        cleaned text_dict
    """
    total_spans_before = 0
    total_duplicates = 0
    duplicate_details = []  # 记录重复详情（可选）

    # 遍历所有 blocks
    blocks_to_remove = []
    for block_idx, block in enumerate(text_dict["blocks"]):
        if block["type"] != 0:  # 只处理文本块
            continue

        # 收集当前 block 所有 spans
        all_spans = []
        for line_idx, line in enumerate(block["lines"]):
            for span_idx, span in enumerate(line["spans"]):
                all_spans.append({
                    "span": span,
                    "block_idx": block_idx,
                    "line_idx": line_idx,
                    "span_idx": span_idx,
                    "text": span["text"].strip(),
                    "bbox": span["bbox"]
                })

        total_spans_before += len(all_spans)
        if len(all_spans) <= 1:
            continue

        keep = [True] * len(all_spans)
        local_duplicates = 0

        for i in range(len(all_spans)):
            if not keep[i]:
                continue
            for j in range(i + 1, len(all_spans)):
                s1 = all_spans[i]
                s2 = all_spans[j]

                if s1["text"] == s2["text"] and s1["text"] != "" and \
                        bbox_overlap_y(s1["bbox"], s2["bbox"], y_overlap_threshold):
                    keep[j] = False
                    local_duplicates += 1
                    total_duplicates += 1

                    if verbose:
                        duplicate_details.append({
                            "block": block_idx,
                            "text": s1["text"],
                            "color1": s1["span"]["color"],
                            "color2": s2["span"]["color"],
                            "bbox1": s1["bbox"],
                            "bbox2": s2["bbox"]
                        })

        # 从后往前删除不需要保留的 spans
        for i in range(len(all_spans) - 1, -1, -1):
            if not keep[i]:
                span_info = all_spans[i]
                del block["lines"][span_info["line_idx"]]["spans"][span_info["span_idx"]]

        # 删除空的 lines
        for line_idx in range(len(block["lines"]) - 1, -1, -1):
            if len(block["lines"][line_idx]["spans"]) == 0:
                del block["lines"][line_idx]

        # 更新每行的 bbox
        for line in block["lines"]:
            if line["spans"]:  # 只有当行中有 spans 时才更新 bbox
                xs = [s["bbox"][0] for s in line["spans"]] + [s["bbox"][2] for s in line["spans"]]
                ys = [s["bbox"][1] for s in line["spans"]] + [s["bbox"][3] for s in line["spans"]]
                line["bbox"] = [min(xs), min(ys), max(xs), max(ys)]

        # 如果块变空了，标记为待删除
        if len(block["lines"]) == 0:
            blocks_to_remove.append(block_idx)

    # 删除空的 blocks
    for block_idx in reversed(blocks_to_remove):
        del text_dict["blocks"][block_idx]

    if verbose and duplicate_details:
        # === 打印统计信息 ===
        total_spans_after = total_spans_before - total_duplicates
        print(f"📊 去重统计:")
        print(f"   • 处理前总 spans 数量: {total_spans_before}")
        print(f"   • 重复 spans 数量:     {total_duplicates}")
        print(f"   • 去重后 spans 数量:   {total_spans_after}")
        print(
            f"   • 减少比例:           {total_duplicates / total_spans_before * 100:.1f}%" if total_spans_before > 0 else "   • 无 spans")
        print(f"\n🔍 重复内容详情 (前5条):")
        for i, dup in enumerate(duplicate_details[:5]):
            print(f"   {i + 1}. 文本: '{dup['text']}'")
            print(f"       颜色: {dup['color1']} vs {dup['color2']}")
            print(f"       Block: {dup['block']}")
        if len(duplicate_details) > 5:
            print(f"       ... 还有 {len(duplicate_details) - 5} 条")

    return text_dict


def analyze_text_heights(input_data: Union[str, Dict], verbose: bool = True) -> Optional[Dict]:
    """
    分析 PDF 页面文本行高信息，并包含 bbox 坐标。

    Args:
        input_data (Union[str, dict]):
            - str: JSON 文件路径；
            - dict: page.get_text("dict") 返回的 text_dict。
        verbose (bool): 是否打印详细报告，默认 True。

    Returns:
        dict 或 None
    """
    # === 1. 加载数据 ===
    if isinstance(input_data, str):
        if not os.path.isfile(input_data):
            raise FileNotFoundError(f"JSON 文件不存在: {input_data}")
        with open(input_data, 'r', encoding='utf-8') as f:
            data = json.load(f)
    elif isinstance(input_data, dict):
        data = input_data
    else:
        raise TypeError("input_data 必须是 JSON 文件路径（str）或 text_dict 字典（dict）")

    # === 2. 提取行信息：height, text, bbox ===
    line_info: List[Dict] = []

    for block in data.get("blocks", []):
        if block.get("type") != 0:
            continue
        for line in block.get("lines", []):
            bbox = line.get("bbox")
            if not bbox or len(bbox) != 4:
                continue
            height = float(bbox[3] - bbox[1])
            text = "".join(span.get("text", "") for span in line.get("spans", []))
            line_info.append({
                "height": height,
                "text": text.strip(),
                "bbox": [round(float(coord), 3) for coord in bbox]  # 标准化为 list[float]
            })

    if not line_info:
        if verbose:
            print("⚠️ 未找到任何文本行")
        return None

    heights = [item["height"] for item in line_info]
    max_h = max(heights)
    min_h = min(heights)
    avg_h = sum(heights) / len(heights)

    # 排序
    sorted_desc = sorted(line_info, key=lambda x: x["height"], reverse=True)
    sorted_asc = sorted(line_info, key=lambda x: x["height"])
    sorted_by_avg_diff = sorted(line_info, key=lambda x: abs(x["height"] - avg_h))

    # 取前2
    highest_two = sorted_desc[:5]
    lowest_two = sorted_asc[:5]
    average_like_two = sorted_by_avg_diff[:5]

    # 构建结果
    result = {
        "max_height": round(max_h, 3),
        "min_height": round(min_h, 3),
        "avg_height": round(avg_h, 3),
        "total_lines": len(line_info),
        "highest_texts": highest_two,
        "lowest_texts": lowest_two,
        "average_like_texts": average_like_two
    }

    # === 打印报告 ===
    if verbose:
        def _format_bbox(bbox):
            return f"[{', '.join(f'{x:.2f}' for x in bbox)}]"

        print("=" * 70)
        print("📏 行高分析报告（含 bbox 坐标）")
        print("=" * 70)
        print(f"总行数:       {result['total_lines']}")
        print(f"最高行高:     {result['max_height']} pt")
        print(f"最低行高:     {result['min_height']} pt")
        print(f"平均行高:     {result['avg_height']} pt")

        print("\n🔝 行高最高的前2行:")
        for i, item in enumerate(result["highest_texts"], 1):
            print(f"  {i}. [{item['height']:.3f} pt] bbox={_format_bbox(item['bbox'])}")
            print(f"      文本: {item['text']}")

        print("\n🔽 行高最低的前2行:")
        for i, item in enumerate(result["lowest_texts"], 1):
            print(f"  {i}. [{item['height']:.3f} pt] bbox={_format_bbox(item['bbox'])}")
            print(f"      文本: {item['text']}")

        print("\n📊 最接近平均行高的前2行（典型正文）:")
        for i, item in enumerate(result["average_like_texts"], 1):
            diff = abs(item["height"] - result["avg_height"])
            print(f"  {i}. [{item['height']:.3f} pt, Δ={diff:.3f}] bbox={_format_bbox(item['bbox'])}")
            print(f"      文本: {item['text']}")

        print("=" * 70)

    return result


def analyze_spans_keys(input_data: Union[str, Dict], verbose: bool = True) -> Optional[Dict]:
    """
    统计 span 字段频次，并按语义类别分组输出。
    """
    # === 1. 加载数据 ===
    if isinstance(input_data, str):
        if not os.path.isfile(input_data):
            raise FileNotFoundError(f"JSON 文件不存在: {input_data}")
        with open(input_data, 'r', encoding='utf-8') as f:
            data = json.load(f)
    elif isinstance(input_data, dict):
        data = input_data
    else:
        raise TypeError("input_data 必须是 JSON 文件路径（str）或 text_dict 字典（dict）")

    # === 2. 收集所有 span keys ===
    all_keys = []
    total_spans = 0

    for block in data.get("blocks", []):
        if block.get("type") != 0:
            continue
        for line in block.get("lines", []):
            for span in line.get("spans", []):
                all_keys.extend(span.keys())
                total_spans += 1

    if total_spans == 0:
        if verbose:
            print("⚠️ 未找到任何 span")
        return None

    key_counter = Counter(all_keys)
    unique_keys = sorted(key_counter.keys())

    # === 3. 定义字段分类规则 ===
    CATEGORIES = {
        "📍 几何位置 (Geometry)": {"bbox", "origin"},
        "🔤 文本内容 (Content)": {"text"},
        "🪴 字体信息 (Font)": {"font", "size", "flags"},
        "🎨 样式与颜色 (Style)": {"color", "alpha"},
        "📐 字体度量 (Metrics)": {"ascender", "descender"},
        "🧭 排版控制 (Layout)": {"bidi", "char_flags"},
        "❓ 未分类字段": set()
    }

    # 找出未被归类的字段
    all_known_fields = set().union(*CATEGORIES.values()) - {"❓ 未分类字段"}
    unclassified = set(unique_keys) - all_known_fields
    if unclassified:
        CATEGORIES["❓ 未分类字段"] = unclassified
    else:
        CATEGORIES.pop("❓ 未分类字段", None)

    # === 4. 构建结果 ===
    result = {
        "total_spans": total_spans,
        "unique_keys": unique_keys,
        "key_counts": dict(key_counter),
        "categories": {}
    }

    # 填充分类结果
    for cat_name, fields in CATEGORIES.items():
        included = {k: key_counter[k] for k in fields if k in key_counter}
        if included:
            result["categories"][cat_name] = included

    # === 5. 打印报告 ===
    if verbose:
        print("🔍 Span 字段（Key）统计与分类报告")
        print("=" * 60)
        print(f"总 span 数量: {total_spans}")
        print(f"唯一字段数:   {len(unique_keys)}")

        print("\n📊 按类别分组统计:")
        for cat_name, fields in result["categories"].items():
            print(f"\n{cat_name}:")
            for key in sorted(fields):
                count = fields[key]
                pct = count / total_spans * 100
                print(f"  • {key:<12} : {count:>3} 次 ({pct:>5.1f}%)")

        # 原始频次表（可选保留）
        # print("\n📋 所有字段频次（按字母排序）:")
        # for key in sorted(key_counter):
        #     count = key_counter[key]
        #     pct = count / total_spans * 100
        #     print(f"  {key:<12} : {count:>6} 次 ({pct:>5.1f}%)")

        print("=" * 60)

    return result


def analyze_spans_value_distribution(input_data: Union[str, Dict], verbose: bool = True) -> Optional[Dict]:
    """
    分析 spans 中每个字段（key）的 *取值分布*，并按语义分类输出。

    Args:
        input_data: JSON 文件路径 或 page.get_text("dict") 返回的字典
        verbose: 是否打印详细报告

    Returns:
        dict: 包含各字段的取值频次统计
    """
    # === 1. 加载数据 ===
    if isinstance(input_data, str):
        if not os.path.isfile(input_data):
            raise FileNotFoundError(f"JSON 文件不存在: {input_data}")
        with open(input_data, 'r', encoding='utf-8') as f:
            data = json.load(f)
    elif isinstance(input_data, dict):
        data = input_data
    else:
        raise TypeError("input_data 必须是 JSON 文件路径（str）或 text_dict 字典（dict）")

    # === 2. 收集每个 key 的所有取值 ===
    key_values = defaultdict(list)
    total_spans = 0

    for block in data.get("blocks", []):
        if block.get("type") != 0:
            continue
        for line in block.get("lines", []):
            for span in line.get("spans", []):
                for key, value in span.items():
                    # 标准化值：确保可哈希（如 bbox 转为 tuple）
                    if isinstance(value, list):
                        value = tuple(round(x, 3) if isinstance(x, float) else x for x in value)
                    elif isinstance(value, float):
                        value = round(value, 3)
                    key_values[key].append(value)
                total_spans += 1

    if total_spans == 0:
        if verbose:
            print("⚠️ 未找到任何 span")
        return None

    # === 3. 对每个 key 统计值分布 ===
    distribution = {}
    for key, values in key_values.items():
        counter = Counter(values)
        distribution[key] = {
            "total_occurrences": len(values),
            "unique_values": len(counter),
            "value_counts": dict(counter.most_common())  # 按频次降序
        }

    # === 4. 定义分类规则 ===
    CATEGORIES = {
        "📍 几何位置 (Geometry)": {"bbox", "origin"},
        "🔤 文本内容 (Content)": {"text"},
        "🪴 字体信息 (Font)": {"font", "size", "flags"},
        "🎨 样式与颜色 (Style)": {"color", "alpha"},
        "📐 字体度量 (Metrics)": {"ascender", "descender"},
        "🧭 排版控制 (Layout)": {"bidi", "char_flags"},
        "❓ 未分类字段": set(distribution.keys())
    }

    # 移除已分类的字段
    known = set().union(*[fields for cat, fields in CATEGORIES.items() if cat != "❓ 未分类字段"])
    CATEGORIES["❓ 未分类字段"] = set(distribution.keys()) - known
    if not CATEGORIES["❓ 未分类字段"]:
        CATEGORIES.pop("❓ 未分类字段")

    # === 5. 打印报告 ===
    if verbose:
        print("📊 Span 字段取值分布分析报告")
        print("=" * 70)
        print(f"总 span 数量: {total_spans}")
        print(f"包含字段数:   {len(distribution)}")

        for cat_name, keys_in_cat in CATEGORIES.items():
            relevant_keys = [k for k in keys_in_cat if k in distribution]
            if not relevant_keys:
                continue

            print(f"\n{cat_name}:")
            print("-" * 50)
            for key in sorted(relevant_keys):
                stats = distribution[key]
                print(f"\n  🔑 '{key}'")
                print(f"    总出现次数: {stats['total_occurrences']}")
                print(f"    唯一取值数: {stats['unique_values']}")

                # 只展示前 N 个高频值（避免 text 太长）
                top_n = 5 if key == "text" else min(10, stats["unique_values"])
                value_counts = list(stats["value_counts"].items())[:top_n]

                for i, (val, count) in enumerate(value_counts, 1):
                    pct = count / total_spans * 100
                    # 对 text 做截断显示
                    display_val = repr(
                        val) if key != "text" else f'"{str(val)[:30]}{"..." if len(str(val)) > 30 else ""}"'
                    print(f"      {i}. {display_val:<40} : {count:>3} 次 ({pct:>5.1f}%)")

                if stats["unique_values"] > top_n:
                    print(f"      ... 还有 {stats['unique_values'] - top_n} 个其他取值")

        print("\n" + "=" * 70)

    return distribution


def replace_text_with_image_in_text_dict(text_dict: dict, roi_bbox: Tuple[float, float, float, float], image_info: Union[str, Dict] = None) -> (dict, list):
    """
    在text_dict中将指定ROI区域的内容替换为图片或其他信息，并返回修改后的text_dict以及被删除的块列表。

    Args:
        text_dict: 通过page.get_text('dict', sort=True)获取的页面字典数据
        roi_bbox: ROI区域坐标 (x0, y0, x1, y1)
        image_info: 图片路径字符串或包含图片信息的字典

    Returns:
        tuple: 修改后的text_dict和被删除的块列表
    """
    # 将ROI转换为fitz.Rect以便与文本框比较
    roi_rect = fitz.Rect(roi_bbox)

    removed_blocks = []  # 存储被删除的块

    # 遍历所有块，从最后一个开始遍历以避免索引问题
    for block_idx in range(len(text_dict["blocks"]) - 1, -1, -1):
        block = text_dict["blocks"][block_idx]

        if block["type"] != 0:  # 只处理文本块
            continue

        # 检查块是否与ROI相交
        block_rect = fitz.Rect(block["bbox"])
        if not roi_rect.intersects(block_rect):
            continue

        # 标记需要移除的行
        lines_to_remove = []
        for line_idx in range(len(block["lines"]) - 1, -1, -1):
            line = block["lines"][line_idx]

            line_rect = fitz.Rect(line["bbox"])
            if not roi_rect.intersects(line_rect):
                continue

            # 标记需要移除的span
            spans_to_remove = []
            for span_idx in range(len(line["spans"]) - 1, -1, -1):
                span = line["spans"][span_idx]

                span_rect = fitz.Rect(span["bbox"])
                if roi_rect.intersects(span_rect):
                    spans_to_remove.append(span_idx)

            # 移除与ROI相交的span
            for span_idx in spans_to_remove:
                del line["spans"][span_idx]

            # 如果该行没有span了，则标记整个行为待移除
            if len(line["spans"]) == 0:
                lines_to_remove.append(line_idx)

        # 移除与ROI相交的行
        for line_idx in lines_to_remove:
            del block["lines"][line_idx]

        # 如果该块没有行了，则标记整个块为待移除并保存该块的副本
        if len(block["lines"]) == 0:
            import copy
            removed_blocks.append(copy.deepcopy(block))  # 使用deepcopy方法来确保完整复制
            del text_dict["blocks"][block_idx]

    # 在ROI区域添加新的内容
    if image_info is not None:
        x0, y0, _, _ = roi_bbox
        x, y = max(0, x0), max(0, y0)
        if isinstance(image_info, str):  # 如果是图片路径
            placeholder_text = "[IMAGE]"
        else:  # 假设是一个符合结构的字典
            placeholder_text = image_info.get("text", "[IMAGE]")

        # 创建一个包含占位符的span
        image_span = {
            "size": 12.0,
            "flags": 0,
            "bidi": 0,
            "char_flags": 16,
            "font": "Helvetica",
            "color": 0,
            "alpha": 255,
            "ascender": 0.89,
            "descender": -0.28,
            "text": placeholder_text,
            "origin": [x, y],
            "bbox": [x, y, x + 100, y + 20]  # 这个宽度和高度是近似值
        }

        # 创建一个包含这个span的行
        image_line = {
            "spans": [image_span],
            "wmode": 0,
            "dir": [1.0, 0.0],
            "bbox": [x, y, x + 100, y + 20]
        }

        # 创建一个包含这个行的块
        image_block = {
            "number": len(text_dict["blocks"]) + 1,
            "type": 0,
            "bbox": [x, y, x + 100, y + 20],
            "lines": [image_line]
        }

        # 将这个块添加到text_dict中
        text_dict["blocks"].append(image_block)

        # 重新排序blocks，以确保顺序正确
        text_dict["blocks"].sort(key=lambda b: b["bbox"][1])

    return text_dict, removed_blocks


def replace_text_with_image_in_roi(
        text_dict: Dict,
        roi_bbox: Tuple[float, float, float, float],
        is_only_process_blocks_text: bool = False,
        verbose: bool = True,
        image_path: Optional[str] = None,
        image_data: Optional[bytes] = None,

) -> Tuple[Dict, List[Dict], List[str]]:
    """
    在 text_dict 中将 ROI 区域内的文本 block (type=0) 替换为图片占位 block，
    并完整记录：
      - 被移除的原始 block（结构完整）
      - 每个被移除 block 的纯文本内容

    Args:
        text_dict: 通过page.get_text('dict', sort=True)获取的页面字典数据
        roi_bbox: ROI区域坐标 (x0, y0, x1, y1)
        is_only_process_blocks_text: 是否仅处理文本块 , 默认值 False
        verbose: 是否打印详细报告, 默认值 False
        image_path: 图片路径（可选），如果不提供则创建占位符图片信息
        image_data: 内存中的图片数据（二进制）（可选）

    Returns:
        tuple: (
            修改后的页面字典,
            被删除的块列表,
            被删除块中的文本内容列表
        )
    """

    def bboxes_overlap(a, b):
        return not (a[2] <= b[0] or a[0] >= b[2] or a[3] <= b[1] or a[1] >= b[3])

    def extract_block_text(block: Dict) -> str:
        """从 block 中提取所有 span 文本，按顺序拼接"""
        text_parts = []
        for line in block.get("lines", []):
            for span in line.get("spans", []):
                text_parts.append(span.get("text", ""))
        return "".join(text_parts)

    # 解构 ROI 坐标
    x0, y0, x1, y1 = roi_bbox

    kept_blocks = []
    removed_blocks = []
    removed_texts = []

    for block in text_dict.get("blocks", []):
        block_bbox = block.get("bbox")
        if is_only_process_blocks_text:
            """方式1 只处理文本的block"""
            if block.get("type") == 0:  # 只处理文本块
                # 检查文本块是否与ROI区域重叠
                if block_bbox and bboxes_overlap(block_bbox, roi_bbox):
                    # 深拷贝以避免副作用，并添加到被删除块列表中
                    removed_blocks.append(copy.deepcopy(block))
                    removed_texts.append(extract_block_text(block))
                    continue  # 跳过该块，相当于删除
            kept_blocks.append(block)
        else:
            """方式2 处理所有block """
            # 检查文本块是否与ROI区域重叠
            if block_bbox and bboxes_overlap(block_bbox, roi_bbox):
                # 深拷贝以避免副作用，并添加到被删除块列表中
                removed_blocks.append(copy.deepcopy(block))
                removed_texts.append(extract_block_text(block))
                continue  # 跳过该块，相当于删除
            else:
                # 不重叠，保留这个块
                kept_blocks.append(block)

    # 创建一个新的图片块，符合PyMuPDF的dict格式
    image_block = {
        "number": len(text_dict.get("blocks", [])),  # 图片编号
        "type": 1,  # 图片块类型
        "bbox": (x0, y0, x1, y1),  # 使用tuple类型保持与文本块一致
        "width": x1 - x0,
        "height": y1 - y0,
        "ext": "png",  # 默认扩展名
        "colorspace": 3,  # colorspace (1=grayscale, 3=rgb)
        "xres": 96,  # 水平分辨率 (dots per inch)
        "yres": 96,  # 垂直分辨率 (dots per inch)
        "bpc": 8,  # bits per component
        "transform": (x1 - x0, 0.0, 0.0, y1 - y0, x0, y0),  # 变换矩阵
        "size": 0,  # 文件大小
        "image": None,  # 图片数据
        "mask": None,  # 遮罩
    }
    # 根据提供的参数填充图片信息
    if image_path:
        try:
            if image_path:
                image_path_str = str(image_path)
                if os.path.exists(image_path_str):
                    image_block["image_path"] = image_path_str
                    # 根据文件扩展名设置ext字段
                    if image_path_str.lower().endswith(('.jpg', '.jpeg')):
                        image_block["ext"] = "jpeg"
                    elif image_path_str.lower().endswith('.png'):
                        image_block["ext"] = "png"

                    # 尝试获取文件大小
                    try:
                        image_block["size"] = os.path.getsize(image_path_str)
                    except Exception:
                        pass

                    # 尝试获取图像的分辨率信息
                    try:
                        with Image.open(image_path_str) as img:
                            image_block["xres"] = img.info.get('dpi', (96, 96))[0] if 'dpi' in img.info else 96
                            image_block["yres"] = img.info.get('dpi', (96, 96))[1] if 'dpi' in img.info else 96
                            image_block["width"] = x1 - x0  # 强制使用roi_bbox的宽度
                            image_block["height"] = y1 - y0  # 强制使用roi_bbox的高度
                    except Exception:
                        pass

                    # 尝试读取图片数据
                    try:
                        with open(image_path_str, "rb") as f:
                            image_data = f.read()
                            image_block["image"] = image_data
                    except Exception:
                        pass  # 无法读取图片数据时忽略
        except Exception as e:
            print(f"Error processing image data: {e}")
    else:
        try:
            if image_data:
                image_block["image"] = image_data
                try:
                    img = Image.open(BytesIO(image_data))
                    image_block["ext"] = img.format.lower()
                    image_block["width"], image_block["height"] = x1 - x0, y1 - y0  # 强制使用roi_bbox的宽高
                    image_block["xres"] = img.info.get('dpi', (96, 96))[0] if 'dpi' in img.info else 96
                    image_block["yres"] = img.info.get('dpi', (96, 96))[1] if 'dpi' in img.info else 96
                except Exception:
                    pass  # 忽略错误，只添加基本的图片信息
        except Exception as e:
            print(f"Error processing image data: {e}")

    # 添加图片块到过滤后的块列表
    kept_blocks.append(image_block)

    # 创建新的页面字典
    new_text_dict = copy.deepcopy(text_dict)
    new_text_dict["blocks"] = kept_blocks

    # === 5. 打印报告 ===
    if verbose:
        print("📊 Blocks 分析报告")
        print("=" * 70)
        # 输出结果验证
        print("=== Removed Blocks ===")
        for i, blk in enumerate(removed_blocks):
            print(f"Block {i}: {blk.get('type')}, bbox={blk.get('bbox')}")

        print("\n=== New Blocks Count ===")
        print(f"Original blocks: {len(text_dict['blocks'])}")
        print(f"New blocks: {len(new_text_dict['blocks'])}")
        print(f"Removed: {len(removed_blocks)}")
        print("=== 被删除的文本内容 ===")
        for i, text in enumerate(removed_texts):
            print(f"[Block {i}] {repr(text)}")
        print("\n=== 被删除的 block 数量 ===")
        print(f"Blocks: {len(removed_blocks)}, Texts: {len(removed_texts)}")
        print("\n" + "=" * 70)
    return new_text_dict, removed_blocks, removed_texts


def insert_image_by_roi(page, image_roi_bbox: Tuple[float, float, float, float], image_path: str):
    """
    根据给定的坐标位置在PDF页面上插入一张图片。

    Args:
        page: PDF文档页面对象 (fitz.Page)
        image_roi_bbox: 图片放置的位置和大小 (x0, y0, x1, y1)
        image_path: 图片文件路径
    """
    if not os.path.exists(image_path):
        raise FileNotFoundError(f"Image file not found at path: {image_path}")

    try:
        # 创建一个矩形框，表示图片放置的位置和大小
        rect = fitz.Rect(image_roi_bbox)
        # 在指定位置插入图片
        page.insert_image(rect, filename=image_path)

        print(f"Image inserted successfully at {image_roi_bbox}")
    except Exception as e:
        print(f"Error inserting image: {e}")


# --- 辅助函数 ---
def _get_pages_to_process(page_range, total_pages, zero_based):
    """统一的页面列表生成逻辑。"""
    if page_range is None:
        return list(range(total_pages))
    elif isinstance(page_range, int):
        page_num = page_range if zero_based else page_range - 1
        if page_num < 0 or page_num >= total_pages:
            raise ValueError(f"页码 {page_range} 超出范围 [1, {total_pages}]")
        return [page_num]
    elif isinstance(page_range, list):
        pages_to_process = []
        for num in page_range:
            page_num = num if zero_based else num - 1
            if page_num < 0 or page_num >= total_pages:
                raise ValueError(f"页码 {num} 超出范围 [1, {total_pages}]")
            pages_to_process.append(page_num)
        return pages_to_process
    elif isinstance(page_range, tuple) and len(page_range) == 2:
        # 处理范围元组 (start, end)
        start, end = page_range
        start = start if zero_based else start - 1
        end = end if zero_based else end - 1
        start = max(0, start)
        end = min(total_pages - 1, end)
        return list(range(start, end + 1))
    else:
        raise ValueError("page_range 必须是 int, list, tuple 或 None")


def _apply_redactions_to_page(page: fitz.Page, roi: Tuple[int, int, int, int]):
    """在指定页面应用编辑删除。"""
    roi_rect = fitz.Rect(roi)
    page_rect = page.rect
    regions = []

    if roi_rect.y0 > page_rect.y0:
        regions.append(fitz.Rect(page_rect.x0, page_rect.y0, page_rect.x1, roi_rect.y0))
    if roi_rect.y1 < page_rect.y1:
        regions.append(fitz.Rect(page_rect.x0, roi_rect.y1, page_rect.x1, page_rect.y1))
    if roi_rect.x0 > page_rect.x0:
        regions.append(fitz.Rect(page_rect.x0, roi_rect.y0, roi_rect.x0, roi_rect.y1))
    if roi_rect.x1 < page_rect.x1:
        regions.append(fitz.Rect(roi_rect.x1, roi_rect.y0, page_rect.x1, roi_rect.y1))

    for rect in regions:
        page.add_redact_annot(rect)
    page.apply_redactions()


def _draw_white_mask_on_page(page: fitz.Page, roi: Tuple[int, int, int, int]):
    """在指定页面绘制白色遮罩。"""
    roi_rect = fitz.Rect(roi)
    page_rect = page.rect
    regions_to_cover = []

    if roi_rect.y0 > page_rect.y0:
        regions_to_cover.append(fitz.Rect(page_rect.x0, page_rect.y0, page_rect.x1, roi_rect.y0))
    if roi_rect.y1 < page_rect.y1:
        regions_to_cover.append(fitz.Rect(page_rect.x0, roi_rect.y1, page_rect.x1, page_rect.y1))
    if roi_rect.x0 > page_rect.x0:
        regions_to_cover.append(fitz.Rect(page_rect.x0, roi_rect.y0, roi_rect.x0, roi_rect.y1))
    if roi_rect.x1 < page_rect.x1:
        regions_to_cover.append(fitz.Rect(roi_rect.x1, roi_rect.y0, page_rect.x1, roi_rect.y1))

    for rect in regions_to_cover:
        page.draw_rect(rect=rect, fill=(1, 1, 1), color=None, overlay=True)
